
上一篇把系列的路線圖交代完了,這篇開始動手,把貫穿整個系列的 todo-api 專案建起來,確認 build、run、test 都能跑
Ktor 官方有提供 start.ktor.io 產生器,勾一勾 plugin 就能下載一個完整專案,趕時間的話直接用它沒問題,不過這個系列不走這條路,理由是產生器塞進來的東西不少,plugin、設定檔、範例程式一次到位,反而分不出哪些是必要的,這個系列想讓每個相依都是自己加進來的,加的時候就知道它為什麼在那裡,所以我們手寫一個最小的 Gradle 專案,2 個 Gradle 設定檔、一個 main、一個測試,總共就這些
Ktor 3.5.2
embeddedServer 跑起來,用 curl 打到第 1 個回應.gitignore
上一個系列 Relix day 02 做的是同一件事,那時用的是 Kotlin Toolchain CLI,這次換成 Gradle,原因是 2 個系列要的東西不一樣
Relix 從頭到尾只靠 JDK 內建的類別,一個外部相依都沒有,module.yaml 寫一行 product: jvm/app 就夠用,建置設定越少越好,注意力才留得住,這個系列剛好相反,Ktor 本身就是一組模組拼起來的,後面還會接上 Exposed、Flyway、Testcontainers,day 29 的 OpenAPI 文件產生器更是以 Gradle plugin 的形式提供,沒有 Gradle 就用不了,加上 Ktor 官方文件、範例、第三方套件的說明都以 Gradle 為主,查資料時比較好對照,所以這次就走這條路
先確認本機的 JDK
java --version
我實測時的版本是
openjdk 21.0.11 2026-04-21 LTS
OpenJDK Runtime Environment Temurin-21.0.11+10 (build 21.0.11+10-LTS)
OpenJDK 64-Bit Server VM Temurin-21.0.11+10 (build 21.0.11+10-LTS, mixed mode, sharing)
再確認 Gradle
gradle --version
Gradle 9.7.1
全域的 gradle 指令只會在建專案時用到,等一下產生 wrapper 之後,就一律改用專案內的 ./gradlew
先建目錄,package 用 com.cashwu.todo
mkdir todo-api
cd todo-api
mkdir -p src/main/kotlin/com/cashwu/todo
mkdir -p src/test/kotlin/com/cashwu/todo
在專案根目錄的 settings.gradle.kts 加上
rootProject.name = "todo-api"
dependencyResolutionManagement {
repositories {
mavenCentral()
}
versionCatalogs {
create("ktorLibs") {
from("io.ktor:ktor-version-catalog:3.5.2")
}
}
}
重點是 versionCatalogs 那一段,Ktor 官方發佈了一個 version catalog,coordinates 是 io.ktor:ktor-version-catalog:3.5.2,引入之後所有 Ktor 模組的版本就一起鎖在 3.5.2,之後每加一個 Ktor 相依都不用再寫版本號,也不會發生模組之間版本不一致的問題,這就是 day 01 說的「版本統一放在一個檔案裡」,要升版時只改這一行
另一個要注意的地方是 repositories 放在 dependencyResolutionManagement 裡,而不是照舊習慣寫在 build.gradle.kts,這不是風格問題,放錯地方 catalog 會直接解析失敗,原因後面的陷阱段落會講
在專案根目錄的 build.gradle.kts 加上
import org.gradle.api.tasks.testing.logging.TestExceptionFormat
plugins {
kotlin("jvm") version "2.2.20"
application
}
application {
mainClass.set("com.cashwu.todo.ApplicationKt")
}
dependencies {
implementation(ktorLibs.server.core)
implementation(ktorLibs.server.netty)
implementation("ch.qos.logback:logback-classic:1.6.3")
testImplementation(kotlin("test"))
}
tasks.test {
useJUnitPlatform()
testLogging {
events("passed", "failed")
exceptionFormat = TestExceptionFormat.FULL
showStackTraces = false
}
}
幾個地方說明一下
ktorLibs.server.core 和 ktorLibs.server.netty,這就是 version catalog 的 accessor 寫法,命名規則是把 artifact 名去掉 ktor- 前綴,dash 換成一層層的點,所以 ktor-server-core 變成 ktorLibs.server.core,之後系列裡每加一個新模組都照同一個規則,server.core 是 Ktor server 的核心 API,server.netty 是實際監聽 port 的 engine,兩者的分工 day 04 會拆開來講1.6.3,1.5.18 有 CVE-2025-11226(1.5.19 修掉)、1.5.24 以前有 CVE-2026-1225(1.5.25 修掉),2 個都是設定檔被竄改就能執行任意類別的問題,版本一有問題 IDE 就會標紅底線提醒你,少了 logback 程式照樣能跑,但你會看不到任何啟動訊息,這也是後面陷阱段落的其中一個mainClass,指向 ApplicationKt,這是 Kotlin 的慣例,Application.kt 這個檔案裡的 top-level main 函式,編譯後會放在名為 ApplicationKt 的類別裡testLogging 那 3 行,Gradle 預設只告訴你「有測試失敗,去看 HTML 報告」,命令列上什麼細節都沒有,events("passed", "failed") 讓每個測試各印一行通過或失敗,exceptionFormat 預設是 SHORT,失敗只會印例外的類別名加一行位置,看不到「期望什麼、實際拿到什麼」,換成 TestExceptionFormat.FULL 才會帶上那句訊息,代價是它連整串 stack trace 一起印,測試跑在 coroutine 上,一個失敗就是 10 幾行雜訊,所以再加 showStackTraces = false,留訊息、不要 stack trace,這個系列後面每次「跑測試看失敗訊息」都是靠這 3 行,TestExceptionFormat 要在檔案最上面 import在 src/main/kotlin/com/cashwu/todo/Application.kt 加上
package com.cashwu.todo
import io.ktor.server.application.Application
import io.ktor.server.engine.embeddedServer
import io.ktor.server.netty.Netty
import io.ktor.server.response.respondText
import io.ktor.server.routing.get
import io.ktor.server.routing.routing
fun main() {
embeddedServer(Netty, port = 8080, module = Application::module).start(wait = true)
}
fun Application.module() {
routing {
get("/") {
call.respondText("Hello, Ktor!")
}
}
}
embeddedServer 用 Netty 當 engine,在 8080 開一個 server,module 裡註冊一個回應純文字的路由,這裡先把它當成固定寫法就好,Application 和 engine 的關係、module 為什麼長這樣,是 day 04 的主題,routing 這個 DSL 則是 day 05 的主題,這篇不展開
在 src/test/kotlin/com/cashwu/todo/EnvironmentTest.kt 加上
package com.cashwu.todo
import kotlin.test.Test
import kotlin.test.assertTrue
class EnvironmentTest {
@Test
fun `Ktor EmbeddedServer class is available`() {
val clazz = Class.forName("io.ktor.server.engine.EmbeddedServer")
assertTrue(clazz.methods.isNotEmpty())
}
}
這個測試跟 Relix day 02 補的 HttpServer 環境測試是同一個用意,不是在測 Ktor,而是確認相依真的載得到,version catalog 有解析成功、server.core 有進到 classpath,如果 catalog 設定有問題,這裡會先失敗,總比下一篇寫測試慣例時才發現好
你可能會問,為什麼不直接用 testApplication 發一個請求來測 ? 因為 testApplication 是 day 03 的主題,這篇刻意只用 kotlin.test,讓專案骨架保持最小
最後一步,在專案根目錄產生 wrapper
gradle wrapper --gradle-version 9.7.1
之後所有指令一律用 ./gradlew,不用全域的 gradle,概念跟 Relix day 02 講過的專案內 ./kotlin wrapper 一樣,專案自己帶著固定入口,新成員 clone 下來不用先對齊本機工具版本
到這裡,專案長這樣
todo-api/
├── settings.gradle.kts
├── build.gradle.kts
├── gradlew
├── gradlew.bat
├── gradle/
│ └── wrapper/
└── src/
├── main/kotlin/com/cashwu/todo/Application.kt
└── test/kotlin/com/cashwu/todo/EnvironmentTest.kt
跟 start.ktor.io 產出的專案相比少了很多東西,沒有 application.yaml、沒有一排預裝的 plugin,這是刻意的,這些東西後面每一篇會在需要的時候自己加進來
跑 build,這個 task 會連測試一起執行
./gradlew build
我實測的結果,尾段是
> Task :test
EnvironmentTest > Ktor EmbeddedServer class is available() PASSED
BUILD SUCCESSFUL in 5s
7 actionable tasks: 7 executed
Consider enabling configuration cache to speed up this build: https://docs.gradle.org/9.7.1/userguide/configuration_cache_enabling.html
一個測試通過,表示 version catalog 解析成功,Ktor 的類別也真的在 classpath 上
./gradlew run
啟動 log 是
...
15:10:18.374 [main] DEBUG io.netty.buffer.ByteBufUtil -- -Dio.netty.allocator.type: adaptive
15:10:18.374 [main] DEBUG io.netty.buffer.ByteBufUtil -- -Dio.netty.threadLocalDirectBufferSize: 0
15:10:18.374 [main] DEBUG io.netty.buffer.ByteBufUtil -- -Dio.netty.maxThreadLocalCharBufferSize: 16384
15:10:18.374 [main] DEBUG io.netty.bootstrap.ChannelInitializerExtensions -- -Dio.netty.bootstrap.extensions: null
15:10:18.381 [DefaultDispatcher-worker-1] INFO io.ktor.server.Application -- Responding at http://0.0.0.0:8080
能看到這 3 行是 logback 的功勞,沒有它的話這裡會是一片空白。因為 start(wait = true) 會讓 server 一直跑著,這個指令不會自己結束,另開一個視窗打請求
curl http://localhost:8080/
Hello, Ktor!
第 1 個回應到手,確認完之後回原視窗按 Ctrl+C 停掉 server
如果你要跟著系列一路做,建議現在就初始化 git
git init
在專案根目錄的 .gitignore 至少放這些
.gradle/
build/
.idea/
.kotlin/
*.iml
.DS_Store
.gradle/ 和 build/ 是 Gradle 的快取和產出物,.kotlin/ 是 Kotlin 編譯器的快取,這 3 個都是機器自己長出來的,不該進版控,要注意 gradle/wrapper/ 和 gradlew 是要提交的,wrapper 進了版控,別人 clone 下來才有固定入口可以用
照舊習慣把 repositories 寫在 build.gradle.kts,build 會直接失敗
* What went wrong:
Could not resolve all artifacts for configuration 'incomingCatalogForKtorLibs0'.
> Cannot resolve external dependency io.ktor:ktor-version-catalog:3.5.2 because no repositories are defined.
原因是 version catalog 在 settings 階段就要解析,那個時間點 build.gradle.kts 還沒被讀到,裡面定義的 repositories 自然不存在,解法就是像前面那樣,把 repositories 定義在 settings.gradle.kts 的 dependencyResolutionManagement 裡,這樣一般相依也會共用同一份定義,build.gradle.kts 不用再寫一次
repositories 是 unstable,不用理它
照上面那樣寫完,IntelliJ 會在 dependencyResolutionManagement 裡的 repositories 底下畫一條波浪線
'repositories(org.gradle.api.Action<? super org.gradle.api.artifacts.dsl.RepositoryHandler>)'
is marked unstable with @Incubating
@Incubating 是 Gradle 標記「這個 API 之後可能會改」用的,到 Gradle 9.7.1 這個方法還掛著它,看到 unstable 難免會想是不是寫錯了,但這裡沒有別條路可以走,上一個陷阱講過 catalog 在 settings 階段就要解析,repositories 不放這裡就解析不到,所以這個警告是 IDE 的靜態檢查,./gradlew build 不會有任何抱怨,功能也不受影響,看到就當沒看到
真的不想看到那條線的話,在 settings.gradle.kts 第 1 行加上這個就會安靜
@file:Suppress("UnstableApiUsage")
程式照跑,路由也能回應,但啟動時只有幾行 SLF4J 找不到 provider 的警告,看不到 Application started,也看不到之後任何 log。功能正常但沒有 log 的服務,除錯時很吃虧,所以這篇一開始就把它放進相依
day 29 會用到的 OpenAPI Gradle extension 明確要求 Kotlin 2.2.20,用別的版本可能會編譯失敗,一開始就對齊,後面就不用中途改版本,這裡刻意不追最新版,就是為了那個 extension,這也是自己手寫專案的好處,每個版本都是有意識選的
./gradlew,不要用全域 gradle
理由跟 Relix 系列講 ./kotlin 時一樣,讓每個人走專案自己的入口,不會因為本機版本不同產生奇怪差異。wrapper 產生之後,全域的 gradle 就功成身退了
這篇把 todo-api 的骨架準備好了,2 個 Gradle 設定檔加上 wrapper,用官方 version catalog 把所有 Ktor 模組鎖在 3.5.2,一個最小的 embeddedServer 能跑、能回應 curl,一個環境測試確認相依載得到,git 也初始化了
如果你覺得這樣一個一個手寫太麻煩,還有一條比較省事的路,先用 start.ktor.io 產一個專案下來,再把用不到的東西刪掉,對照前面那段專案結構,留下 settings.gradle.kts、build.gradle.kts 跟 wrapper,加上 Application.kt 和測試就夠了,產生器多給的、預裝的 plugin 跟範例程式都可以先拿掉,後面需要哪一個再加回來
2 條路的終點是同一個專案,差別在你是從空的加上去,還是從完整的減下來,減法快,但刪的時候你還不知道哪些刪得掉,所以這篇才選加法,把每個相依進來的理由講一次,真的跟著做的時候用哪一個都行
專案跑得起來,但現在唯一的測試是 Class.forName("io.ktor.server.engine.EmbeddedServer"),它只證明 version catalog 解析成功、jar 進了 classpath,GET / 回不回得了 Hello, Ktor!,到這裡為止是自己開一個視窗打 curl,用眼睛看的,下一篇用官方的 testApplication 把這件事變成測試,不啟動真的 server、不綁 port,請求直接進到 Ktor 的處理流程裡跑完
同步刊登於 Blog
圖片來源:AI 產生